JestでReactアプリのUIテストを自動化してみた(スナップショットテスト)
こんにちは、CX事業本部 IoT事業部の若槻です。
今回は、JavaScriptのテストフレームワークJestを使用して、ReactアプリケーションのUIテストの自動化をしてみました。
ReactアプリのUIテストをするためには
Reactアプリケーションのユーザーインターフェース(UI)のテストを自動化する場合、次のような観点が考えられます。
- DOM要素の出力が期待通りであるかのテスト
- コードの変更前後でDOM要素の出力に差分が発生していないか(又はしているか)のテスト
特に後者のテストは、ある時点の画面のスナップショットを用意したテストとなるため、スナップショットテストと呼びます。
そして、これらいずれのUIテストもJestを使用して自動化が可能です。
やってみた
テスト環境の作成
テスト対象となるReactアプリケーションを新規作成します。
$ npx create-react-app sample-ss-test-app $ cd sample-ss-test-app
react-test-renderer
をインストールしておきます。jestによるReactアプリケーションのテストで必要となります。
$ npm i -D react-test-renderer
react-test-renderer
を使用することにより、ブラウザなどを使わずにReact componentsからDOM tree(正確にはDOM treeに類似したオブジェクト)をレンダリング可能となります。スナップショットテストでは、このDOM treeのスナップショットを作成してテストを行います。
DOM要素の出力が期待通りであるかのテスト
最初に、DOM要素の出力が期待通りであるかのテストを作成してみます。
こちらのドキュメントのExampleを参考にして進めます。
まずはExampleにあるテストファイルtest/example.test.js
を作成して試してみます。
$ mkdir test $ touch test/example.test.js
import TestRenderer from 'react-test-renderer'; function Link(props) { return <a href={props.page}>{props.children}</a>; } const testRenderer = TestRenderer.create( <Link page="https://www.facebook.com/">Facebook</Link> ); console.log(testRenderer.toJSON());
ここでjestを実行するとJest encountered an unexpected token
というエラーとなりました。
$ npx jest test/example.test.js FAIL test/example.test.js ● Test suite failed to run Jest encountered an unexpected token Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax. Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration. By default "node_modules" folder is ignored by transformers. Here's what you can do: • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it. • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config. • If you need a custom transformation specify a "transform" option in your config. • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option. You'll find more details and examples of these config options in the docs: https://jestjs.io/docs/configuration For information about custom transformations, see: https://jestjs.io/docs/code-transformation Details: SyntaxError: /Users/wakatsuki.ryuta/projects/example-ss-test-app/test/example.test.js: Support for the experimental syntax 'jsx' isn't currently enabled (4:10): 2 | 3 | function Link(props) { > 4 | return <a href={props.page}>{props.children}</a>; | ^ 5 | } 6 | 7 | const testRenderer = TestRenderer.create( Add @babel/preset-react (https://git.io/JfeDR) to the 'presets' section of your Babel config to enable transformation. If you want to leave it as-is, add @babel/plugin-syntax-jsx (https://git.io/vb4yA) to the 'plugins' section to enable parsing. at Parser._raise (node_modules/@babel/parser/src/parser/error.js:147:45) at Parser.raiseWithData (node_modules/@babel/parser/src/parser/error.js:142:17) at Parser.expectOnePlugin (node_modules/@babel/parser/src/parser/util.js:205:18) at Parser.parseExprAtom (node_modules/@babel/parser/src/parser/expression.js:1221:16) at Parser.parseExprSubscripts (node_modules/@babel/parser/src/parser/expression.js:670:23) at Parser.parseUpdate (node_modules/@babel/parser/src/parser/expression.js:650:21) at Parser.parseMaybeUnary (node_modules/@babel/parser/src/parser/expression.js:621:23) at Parser.parseMaybeUnaryOrPrivate (node_modules/@babel/parser/src/parser/expression.js:374:14) at Parser.parseExprOps (node_modules/@babel/parser/src/parser/expression.js:384:23) at Parser.parseMaybeConditional (node_modules/@babel/parser/src/parser/expression.js:342:23) Test Suites: 1 failed, 1 total Tests: 0 total Snapshots: 0 total Time: 0.831 s Ran all test suites matching /test\/example.test.js/i.
どうやら@babel/preset-react
が無いためJSXが解釈出来てないみたいですね。
下記を参考に対処を行います。
@babel/preset-react
をインストールし、.babelrc
を作成して記述を追加します。
$ npm i -D @babel/preset-react $ touch .babelrc
{ "presets": ["@babel/preset-env","@babel/preset-react"] }
再度jestを実行すると、エラーの内容が変わりました。React
のインポートを忘れていますね。
$ npx jest test/example.test.js FAIL test/example.test.js ● Test suite failed to run ReferenceError: React is not defined 6 | 7 | const testRenderer = TestRenderer.create( > 8 | <Link page='https://www.facebook.com/'>Facebook</Link> | ^ 9 | ); 10 | 11 | console.log(testRenderer.toJSON()); at Object.<anonymous> (test/example.test.js:8:3) at TestScheduler.scheduleTests (node_modules/@jest/core/build/TestScheduler.js:333:13) Test Suites: 1 failed, 1 total Tests: 0 total Snapshots: 0 total Time: 0.776 s Ran all test suites matching /test\/example.test.js/i.
1行目に下記を追記。
import React from 'react';
jestを実行するとまたFAILとなりましたが、次はtestの記述が一つもないためエラーとなっているだけです。そしてDOM treeをJSON化したオブジェクトを取得できています。
$ npx jest test/example.test.js console.log { type: 'a', props: { href: 'https://www.facebook.com/' }, children: [ 'Facebook' ] } at Object.<anonymous> (test/example.test.js:12:9) FAIL test/example.test.js ● Test suite failed to run Your test suite must contain at least one test. at onResult (node_modules/@jest/core/build/TestScheduler.js:175:18) at node_modules/@jest/core/build/TestScheduler.js:316:17 at node_modules/emittery/index.js:260:13 at Array.map (<anonymous>) at Emittery.emit (node_modules/emittery/index.js:258:23) Test Suites: 1 failed, 1 total Tests: 0 total Snapshots: 0 total Time: 0.849 s Ran all test suites matching /test\/example.test.js/i.
これでやっとExampleのテストを試す準備が整いました。
test/example.test.js
を次のように更新します。
import React from 'react'; import TestRenderer from 'react-test-renderer'; function MyComponent() { return ( <div> <SubComponent foo="bar" /> <p className="my">Hello</p> </div> ) } function SubComponent() { return ( <p className="sub">Sub</p> ); } const testRenderer = TestRenderer.create(<MyComponent />); const testInstance = testRenderer.root; test("snapshots test", () => { expect(testInstance.findByType(SubComponent).props.foo).toBe('bar'); expect(testInstance.findByProps({className: "sub"}).children).toEqual(['Sub']); })
テスト対象のMyComponent
とSubComponent
を使用してtestInstance
を作成し、DOM treeに期待した値が設定されているかテストをしています。
1つ目のテストでは、testInstance
の中で持つSubComponent
のfoo
propがbar
であることを、2つ目のテストでは、testInstance
でclassNamesub
の値がSub
であることをテストしています。
jestを実行すると、テストがPASSしました。
$ npx jest test/example.test.js PASS test/example.test.js ✓ snapshots test (2 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 0.902 s Ran all test suites matching /test\/example.test.js/i.
期待通りとならない場合も試してみます。2つ目のテストでtoEqual
の値をMain
に変更します。
test("snapshots test", () => { expect(testInstance.findByType(SubComponent).props.foo).toBe('bar'); expect(testInstance.findByProps({className: "sub"}).children).toEqual(['Main']); })
jestを実行すると、ちゃんとテストがFAILしました。
$ npx jest test/example.test.js FAIL test/example.test.js ✕ snapshots test (5 ms) ● snapshots test expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 1 Array [ - "Main", + "Sub", ] 22 | test("snapshots test", () => { 23 | expect(testInstance.findByType(SubComponent).props.foo).toBe('bar'); > 24 | expect(testInstance.findByProps({className: "sub"}).children).toEqual(['Main']); | ^ 25 | }) 26 | at Object.<anonymous> (test/example.test.js:24:65) Test Suites: 1 failed, 1 total Tests: 1 failed, 1 total Snapshots: 0 total Time: 0.886 s, estimated 1 s Ran all test suites matching /test\/example.test.js/i.
これでDOM要素の出力が期待通りであるかのテストを作成できました。
スナップショットテスト
次にスナップショットテストを作成してみます。
こちらのドキュメントを参考に進めます。
準備として、テストファイルにJSXをインポートするので、babelの設定が必要となります。
必要なパッケージをインストールします。
$ npm i -D babel-jest @babel/preset-env @babel/preset-react react-test-renderer
次のbabel.config.js
ファイルを作成します。
module.exports = { presets: ['@babel/preset-env', '@babel/preset-react'], };
テスト対象のReact ComponentとなるLink
を作成します。onMouseEnter
およびonMouseLeave
によりDOMの要素が変わるComponentです。
import React, {useState} from 'react'; const STATUS = { HOVERED: 'hovered', NORMAL: 'normal', }; const Link = ({page, children}) => { const [status, setStatus] = useState(STATUS.NORMAL); const onMouseEnter = () => { setStatus(STATUS.HOVERED); }; const onMouseLeave = () => { setStatus(STATUS.NORMAL); }; return ( <a className={status} href={page || '#'} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} > {children} </a> ); }; export default Link;
テストファイルを次の通り作成します。インポートしたLink
のDOM treeに対して、toMatchSnapshot()
を使用して既定時、onMouseEnter時、onMouseLeave時のスナップショットを作成しています。
import React from 'react'; import renderer, { act } from 'react-test-renderer'; import Link from '../src/Link'; test('Link changes the class when hovered', () => { const component = renderer.create( <Link page="http://www.facebook.com">Facebook</Link>, ); let tree = component.toJSON(); expect(tree).toMatchSnapshot(); // manually trigger the callback act(() => { tree.props.onMouseEnter(); }); // re-rendering tree = component.toJSON(); expect(tree).toMatchSnapshot(); // manually trigger the callback act(() => { tree.props.onMouseLeave(); }); // re-rendering tree = component.toJSON(); expect(tree).toMatchSnapshot(); });
ここで注意点として、onMouseEnter時およびonMouseLeave時はReact Hooksによる動作が発生するため、act()
を使用する必要がありました。
console.error Warning: An update to Link inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): act(() => { /* fire events that update state */ }); /* assert on the output */ This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act at Link (/Users/wakatsuki.ryuta/projects/cm-rwakatsuki/sample-ss-test-app-2/src/Link.js:8:16)
jestを実行するとすべてPASSしました。
$ npx jest test/Link.react.test.js PASS test/Link.react.test.js ✓ Link changes the class when hovered (9 ms) › 3 snapshots written. Snapshot Summary › 3 snapshots written from 1 test suite. Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 3 written, 3 total Time: 1.01 s Ran all test suites matching /test\/Link.react.test.js/i.
そして__snapshots__
ディレクトリ配下にLink.react.test.js.snap
というスナップショットファイルが作成されています。
// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Link changes the class when hovered 1`] = ` <a className="normal" href="http://www.facebook.com" onMouseEnter={[Function]} onMouseLeave={[Function]} > Facebook </a> `; exports[`Link changes the class when hovered 2`] = ` <a className="hovered" href="http://www.facebook.com" onMouseEnter={[Function]} onMouseLeave={[Function]} > Facebook </a> `; exports[`Link changes the class when hovered 3`] = ` <a className="normal" href="http://www.facebook.com" onMouseEnter={[Function]} onMouseLeave={[Function]} > Facebook </a> `;
このスナップショットファイルは初回実行時のみtoMatchSnapshot()
により作成されるもので、2回目以降はすでに作成されているスナップショットファイルと、テスト実行時のスナップショットの比較のテストが行われます。これによりソースコードの変更によりComponentのスナップショットに想定外の影響が発生していないかどうかをテストすることができるようになります。
テスト失敗パターンも確認してみます。ホバー時の表示文字列を変更してみます。
const STATUS = { HOVERED: 'hovered!!!', NORMAL: 'normal', };
jestを実行すると、想定通り失敗しました。
$ npx jest test/Link.react.test.js FAIL test/Link.react.test.js ✕ Link changes the class when hovered (15 ms) ● Link changes the class when hovered expect(received).toMatchSnapshot() Snapshot name: `Link changes the class when hovered 2` - Snapshot - 1 + Received + 1 @@ -1,7 +1,7 @@ <a - className="hovered" + className="hovered!!!" href="http://www.facebook.com" onMouseEnter={[Function]} onMouseLeave={[Function]} > Facebook 16 | // re-rendering 17 | tree = component.toJSON(); > 18 | expect(tree).toMatchSnapshot(); | ^ 19 | 20 | // manually trigger the callback 21 | act(() => { at Object.<anonymous> (test/Link.react.test.js:18:16) › 1 snapshot failed. Snapshot Summary › 1 snapshot failed from 1 test suite. Inspect your code changes or re-run jest with `-u` to update them. Test Suites: 1 failed, 1 total Tests: 1 failed, 1 total Snapshots: 1 failed, 2 passed, 3 total Time: 1.896 s Ran all test suites matching /test\/Link.react.test.js/i.
UIに変更が発生するコード変更を行った場合は、合わせてスナップショットファイルを変更し、またGitで他のファイルと一緒にバージョンを管理します。
おわりに
Jestを使用して、ReactアプリケーションのUIテストの自動化をしてみました。
今までReactアプリケーションのUIの動作確認をいちいち画面を見て行っていたため、時間を要したり確認が漏れたりというペインがありました。
今後はJestを活用してUIのテストもガンガン実装していきたいと思います!
参考
以上